Skip to content

feat(plugin-security, runtime, core)!: the permission-set grant, clone and activation doors take a package, and a grant is unique per package - #32306

Merged
objectstack-fleet[bot] merged 23 commits into
mainfrom
claude/project-thread-ekmc12
Nov 12, 2026
Merged

objectstack-fleet[bot] merged 23 commits into
mainfrom
claude/project-thread-ekmc12

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

domain:server#2 · os-bill · session_01Utp6CaP1iy4RSEQbW6177G

维护者速读

  • 改了什么:给用户授权权限集、开关权限集(激活入口)、克隆权限集这三个入口,现在都能用 permission_set_package / ?package= 指明“哪个包的那一个”;同名权限集由两个包持有时,不指明包就拒绝(409)并列出候选包。授权的唯一键加上了包这一列;不带包的授权仍是一人、一个权限集、一个组织只有一条,由上一段(P1b,已合入)建好的守卫保证;同一用户在同一组织里对同一权限集仍只能有一条授权(两个包各一条除外),由引擎里的写入缝把关,拒绝时与旧键的回答一致。权限集的开关台账按“包 + 名字”各占一行,开关一个包的不会碰另一个包的。
  • 为什么改:ADR-0150 允许两个包各有一个同名权限集(objectql: narrow the phase-1 one-holder refusal from environment to namespace for permission set (ADR-0150 child B, permission set family) #31698 PR-2 之后才真正出现),而这三个入口原来只按名字找,遇到同名要么答“不存在”(404/400),要么存错包。本 PR 是 plugin-security + runtime: the grant, clone and activation doors take a permission set's package and report a two-holder name with its candidates (ADR-0150 D3/D8; before #31698 PR-2) #32164 三段里的最后一段(P1 与 P1b 已合入 main),落地后 PR-2 才能放开同名。
  • 风险与回滚:属于收窄(BREAKING,标题带 !)。只有一个包持有时,三个入口的回答与 main 一致:17 项回答的 md5 在本轮代码上重测,与上一轮对照 main 入口源码时的结果逐字节相同,2 项灵敏度对照照样不同。唯一的例外已在 changeset 写明:环境自建且被覆盖层改过的权限集,开关入口写的台账行不再带 sys_metadata 标记而是空包,各读者对两者读法相同。收紧六处:指明一个并不持有该权限集的包会 409(以前静默改写或照存);建议记录指明建议包够不着的持有者会 409;两个入口只认 package 查询参数,其它参数回 400;克隆目标名若被两个包持有算已占用;同名多行台账里有不带包的行时开回会 409;AI 服务账号的权限天花板改按授权所指的那个包来判,判不出是哪个包时任一持有者越线即拒(后两处要到 objectql: narrow the phase-1 one-holder refusal from environment to namespace for permission set (ADR-0150 child B, permission set family) #31698 PR-2 之后才会出现)。第一方没有任何调用会碰到。旧的三列唯一索引只在守卫建成后作为一次 safe 替换退役(本地在 MariaDB 10.11 与 PostgreSQL 16 实测),不会被当作孤儿索引删掉。回滚即还原本 PR,无数据迁移。上一轮补齐了契约复审(6813617327)和专题红线复审(6813516860)的全部条目,两份复审在 ec39bd8e5 上都已通过(6817263634、6817314015)。本轮再补两处:一人一组的检查排到授权表其它拒绝之后,调用方读不到的持有者照旧答 reference_not_found;环境与某个包同名时,带 sys_metadata 哨兵的台账行认作环境自己那一行。另有一处声明过的先后变化:第二条授权若同时也过不了记录校验,以前答 400,现在答 409,两种情况都什么也不存。
  • 先看:packages/plugins/plugin-security/src/stored-permission-set-package-stamp.ts(F3 拒绝 assertNamedPackageHolds,一人一组一权限集 assertOneGrantPerSet),然后 packages/runtime/src/domains/catalog-activation.ts(?package= 定址与按包的台账行)和 packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts(唯一键)。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/domains/permission-set-package-doors.test.ts,预期 38 条全过:两个包持有时不带包回 409 并带两个候选;?package= 指 HR 回 200,只写 HR 自己的台账行,CRM 的行不动。
  • 需要你做的:无。改动共 2920 行,没有碰到受治理的路径(check-governed-merges --branch 判定 0/51)。按现行规则,契约复审和专题红线复审由席位来跑,通过后由席位落地。

Fixes #32164

Clause-②: no (narrowing)

  • Widening, at the grant, clone and activation doors: a permission set two packages hold, addressed with no package: FROM read as absent (the grant's 400 reference_not_found, the clone and activation doors' 404) TO 409 RESOURCE_CONFLICT naming the holders; addressed with a package that holds it (permission_set_package on the grant, ?package= on clone and activation): FROM the package dropped TO exactly that package's set.
  • Widening, the grant's unique key: FROM one grant per (user_id, permission_set, organization_id) TO one per (user_id, permission_set, permission_set_package, organization_id), so one user may hold both packages' sets of one name in one organization. A grant naming no package stays one per (user_id, permission_set, organization_id) through the guard the preceding PR builds, except on driver-memory (declared below).
  • Narrowing (F3), a named package that does not hold the set, at any holder count: FROM the grant or audience-binding suggestion re-stamped to the holder (one holder) or stored with no package (two holders), a system column-only bind landing as written, and ?package= dropped at the clone and activation doors (one holder: the holder's set) TO 409 RESOURCE_CONFLICT, reason not-held-by-qualifier, naming the holders, with nothing stored or switched.
  • Narrowing, an audience-binding suggestion naming a holder outside the reach of the package that made it (not that package, a dependency or a kernel package): FROM re-stamped by the rule's answer (no package) TO 409 RESOURCE_CONFLICT, reason outside-closure, naming that holder, nothing stored.
  • Narrowing, the agent-account ceiling (ADR-0151 D3) for a set two packages hold: FROM read by the bare name, which the rule refuses, so no breach was found and the grant or position was admitted TO the set the row would hold (the grant's own package, a position's sets from the package that declared it) judged, and a read the rule still refuses judging every holder of the name: a breach of any refuses the write.
  • Narrowing, the query sets are closed: FROM any query parameter dropped without a word TO 400 VALIDATION_ERROR before any lookup, at the clone door and at the activation door for all six of its types (position, permission, sharing_rule, webhook, queue, assignment_rule), for anything but package on permission, and for a repeated or blank package.
  • Narrowing, a clone target name two packages hold: FROM free (the reader answers nothing for a refused name, so the copy was saved) TO taken, 409, as a name one package holds already was.
  • Narrowing, the activation ledger for a permission set two packages hold: switching a holder on while a ledger row naming no package, or naming a package that no longer serves the name, keeps it off: FROM (reachable only after objectql: narrow the phase-1 one-holder refusal from environment to namespace for permission set (ADR-0150 child B, permission set family) #31698 PR-2) that row re-addressed to the holder, which re-armed the other holder too TO 409 RESOURCE_CONFLICT naming that row, nothing written (ADR-0150 red line 1). The same with one holder when the name has more than one row, which no first-party writer makes. A disabled package still holds the name here, as the rule counts it.
  • Moved, refused either way: a second grant of one set to one user in one organization whose row record validation would also refuse: FROM 400 VALIDATION_FAILED (the store's key answered after validation) TO the one-grant check's 409, nothing stored either way. Every refusal of the grant table's hooks, and the engine's answer for a holder the caller cannot read, keep their precedence.

Metadata-protocol: none

Skills-docs: follow-up #32539 (skills/objectstack-data/rules/security.md: its grant paragraph still says one grant per user, set and organization is "enforced by the unique: 'organization' index"; after this PR the key is (user_id, permission_set, permission_set_package, organization_id) with the package-less guard and the engine's one-grant check, and a grant may name permission_set_package. This lane does not edit skills/**.)

BREAKING for a writer that sends permission_set_package naming a package that does not hold the permission set (or, on a suggestion, a holder outside the suggesting package's reach), and for a request carrying a query parameter to the clone door or to the activation door, for any of its six types. No first-party writer or caller meets any of the narrowings: the boot backfill and os migrate stored-references bind write only a holder, and the Console sends no query to these doors. Every moved answer, door by door, is in the changeset.

This is the last of three PRs for #32164. P1 (#32356, 48d6b477c7) taught driver-sql's widening arm the superseded grant key's 18.0, shadow-carried and not-yet-physical forms; P1b (#32357, e765a092) builds the grant's package-less guard at schema sync on every kernel that runs the table's DDL. Both are on main, and this branch carries main merged (371bd8d45), so the diff here is P2's own: 51 files, +2748 / -172 (2920 lines). Opened by domain:server#3 (os-support-ai); continued by domain:server#2.

What changes

  • The grant (plugin-security): grant-permission-set-name.ts judges a non-system writer's grant through the security catalog by (package, name) (judgeGrantSet): a set two packages hold, named with no package, is refused with core's PackageScopedNameRefusedError (RESOURCE_CONFLICT, 409, the D3 sentence, .refusal.candidates); a named holder is kept; a named non-holder is refused (not-held-by-qualifier). stored-permission-set-package-stamp.ts keeps a named package that holds the set for every writer and refuses one that does not (assertNamedPackageHolds), on insert, on update and on a column-only bind, for the grant and for sys_audience_binding_suggestion; it never re-stamps a named package. Before the boot seals the holders, a system insert's named package is kept unjudged (the backfill reads a stale one as none). The sys_metadata overlay provenance sentinel reads as no package, as core's catalogEntryAddress reads it. On a suggestion (R3) a named holder must be in the suggesting package's reach (itself, its dependencies, the kernel packages), else 409 outside-closure.
  • One grant of a set per user and organization (plugin-security, the same seam, assertOneGrantPerSet): the two keys alone do not refuse every second grant of one set to one user in one organization that the old key refused, so the seam judges each grant insert, and each update that moves its user, set, organization or package, reading the other grants of that user, set and organization as the system in the writer's transaction. It admits a second grant only when the two name two different packages that each hold the set, and otherwise refuses with the engine's own duplicate-record envelope (DUPLICATE_RECORD, 409; 409 UNIQUE_VIOLATION, "A record with this value already exists", on the wire), the answer the old key gave. The check is its own pair of hooks at priority 50, after every refusal of the grant table's hooks (the set's name 30, the agent ceiling set 35, the holder's membership 40, the AI service account's ceiling 45), so those keep their answer; and, as they do, it steps aside for a non-system write on a holder the write names that the caller's own read does not return, which the engine's reference check answers as a user that does not exist (objectql: a plain lookup write accepts a target the writer cannot read — the reference check probes it elevated, and only master_detail asks the caller's read door #29029; holderLeftToReferenceCheck, now exported by grant-holder-membership-refusal.ts). Record validation runs after every hook, so a second grant it would also refuse answers 409 where main answered 400 (declared). Below the engine, a race, a batch and a write with no organization stay with the keys (declared in the changeset). The boot backfill (stored-permission-set-package-backfill.ts) catches a per-row unique violation, leaves that row with no package and reports it as duplicated, so the walk goes on; a dry run asks the check's own predicate and reports the same rows as duplicated, not bound.
  • The agent-account ceiling (plugin-security, agent-account-grant-refusal.ts, ADR-0151 D3): the set an AI service account would hold is read as the authorization resolver reads it, a grant at its stored package and a position's sets from the position's package; a read the rule still refuses judges every holder of the name and fails closed.
  • The activation door (runtime, catalog-activation.ts): ?package=PKG addresses exactly PKG's set. The permission ledger store is addressed by (package, name) (addressing: 'package-and-name', scoped to permission): each holder keeps its own row, so switching one holder's set never touches the other's. Two holders and no package answer 409 with details.candidates; a non-holder is 409 not-held-by-qualifier; switching a holder on while a package-less or stale row keeps it off is 409 naming that row, nothing written; a write that would re-address a row switched off for another holder is refused. With one holder, a package or the environment, the name's only row is the holder's and the write takes it over, as on main. Holders are counted as the D3 rule counts them, a disabled package included. Where the environment holds the name beside a package, the name alone is the environment's set, so a row stamped with the overlay provenance sys_metadata is the environment's own switch and is re-addressed by id to name no package, never doubled (epic read 6817314015, S1); the refusal's sentence for a package-less row now says when it switches off every holder (the bare name refused). The query set is closed (package, for permission only). Every other type keeps the name-addressed store.
  • The clone door (runtime, permission-set-clone.ts): ?package= names the SOURCE's package (the body's package still names where the copy lands); two holders and no package answer 409 with details.candidates; a target name two packages hold is taken; the query set is closed.
  • core (security-catalog.ts): the refused arm of SecurityCatalogResolution gains an optional holders, the candidates structured, so a door can answer them without re-deriving. Additive.
  • The grant's key (objects/sys-user-permission-set.object.ts): (user_id, permission_set, organization_id), unique: 'organization' → (user_id, permission_set, permission_set_package, organization_id), unique: 'global'. The package-less guard P1b builds keeps grants that name no package to one per (user_id, permission_set, organization_id); the old three-column index retires only once the guard stands, through driver-sql's widening arm (P1) as one safe replace. A store whose old key is carried by a hash shadow (MySQL, MariaDB) keeps it, declared in the changeset with the remedy by hand.
  • metadata-protocol: the guard migration's module doc states the grant's declared key as this change makes it (a comment; no code).
  • Docs: content/docs/permissions/permission-sets.mdx (the grant's package, the clone door's ?package=, the driver-memory limit), authorization.mdx (the activation door's ?package= and the per-package ledger rows), system-context.mdx rows 23c and 23h (the stamp's system column bind, the grant's 409 refusals and the one-grant check's system read; the generated site register regenerated). QA: docs/qa/platform-checklist/areas/access-security.json, item access-security.security-catalog-activation-and-clone-doors revision 4 (the closed query and the two-holder arms).

Why

ADR-0150 D3/D4/D5/D8, a child of #31527 ahead of #31698 PR-2. Once two packages can each hold a permission set of one name, a door that addresses a set by bare name must either be told which one or refuse naming both; a grant must record which one it grants, and the grant's key must let one user hold both. These doors answered a two-holder name as absent (404, or the grant's 400 reference_not_found) and dropped ?package=, and a grant's named package was re-stamped rather than judged.

Rulings applied: seat 3's F1 (?package= on the POST doors; clone's names the source), F2 (an additive holders in core; structured candidates on /data are #32250) and F3 (a named non-holder refused at any holder count, a column-only bind included); the epic seat's M5 conditions (comment 6770072902) and its two rulings after the first contract review (comment 6774878772): the permission ledger keyed by package with the refusal kept as a backstop, and condition 1 closed at schema sync (landed in P1b).

Tests

  • plugin-security/src/grant-permission-set-package.test.ts (new, 19): a real ObjectQL over SQLite with the grant-name hook and the stamp seam registered as the plugin registers them, over a stand-in registry holding one set in two packages. Two holders: no package is 409 with both candidates (envelope and /data wire), a holder is stored, a non-holder is 409; F3: one holder with another package named, an environment-only set with a package named, and a column-only bind; updates: an echo, a non-system package value, a rename; the overlay sentinel. CONTROLs: one holder, no package or the holder, stored stamped; a name nothing holds stays 400 reference_not_found. [E2] The seam's refusal of a second grant equals the store's own unique violation, envelope and wire (measured against the four-column key with the seam's reads off), at the data door too; two holders' sets once each are stored.
  • plugin-security/src/stored-permission-set-package-backfill.test.ts: F3 on sys_audience_binding_suggestion (insert and column-only update naming a non-holder: 409, nothing stored), and a system insert naming a package for an unheld name after the seal (kept as written). [E2] One grant of a set per user and organization at the seam: beside a package-less row or a stale one (one holder), the same package twice and a package-less grant beside a packaged one (two holders), a column-only bind and a move to another user are refused with nothing written; CONTROL: other users, organizations and sets, and an update that keeps the key. The backfill reports a row whose bind would duplicate as duplicated, leaves it null and binds the next row, with and without the seam. [SF3] A suggestion naming a holder outside the suggester's reach is 409 outside-closure on insert and update; its own and a dependency's are kept.
  • plugin-security/src/agent-account-grant-refusal.test.ts [E3]: two holders of one name, one above the ceiling: a grant naming the breaching package is refused and the other admitted, an update keeps the stored package, a grant whose read is refused fails closed (CONTROL: admitted when neither breaches), a position is judged on its own package's set.
  • Per driver, the store alone [E2]: metadata-protocol's guard suites (SQLite, live PostgreSQL 16, live MySQL / MariaDB) and driver-memory's pin assert that the store alone admits a packaged grant beside a package-less one, where the old key refused it, so a later guard turns them red; Mongo's real-server suite carries the same rows, pinned, not run.
  • plugin-security/src/granted-by-writer-provenance.test.ts and grant-permission-set-name.test.ts: pins that encoded the re-stamp now assert F3; the BASE answer is kept in comments. [E2] The without-guard CONTROL now asserts that the engine refuses a second package-less grant and that only a write below the engine lands one.
  • runtime/src/domains/permission-set-package-doors.test.ts (new, 38): both doors through HttpDispatcher.handleSecurity over the real catalog reader. Two holders: 409 with structured candidates; ?package= a holder addresses exactly that set (activation writes that holder's own ledger row, clone copies it into its pair); each holder keeps its own row and both stay independently switchable; a package-less or stale row keeps both off and switching one on is 409; a non-holder is 409; closed query sets are 400; a two-holder target is taken. CONTROLs: one holder answers as before, a legacy package-less or stale row is the one holder's switch, a position row is re-attributed by name as before. [E1] The environment as the one holder, plain and overlay: a stale row and a sys_metadata row are taken over on enable and disable; with no row, a disable writes one naming no package. [SF2] A disabled second holder still counts: the bare name is 409 naming both, a package-less row keeps the served holder off (409), and ?package= the disabled one names the served holder. [S1] A package and the environment hold one name, the environment's set plain and overlay: a sentinel row is the environment's switch on enable and on disable (re-addressed, never a second row); CONTROL: ?package= the package writes its own row and leaves the sentinel row alone.
  • Round 3 [should-fix A]: grant-holder-membership-refusal.test.ts, a holder the caller cannot read that already holds the set gets one answer in every store, the reference refusal at user_id, on insert and on an update that moves a grant onto it (CONTROL: a holder the caller reads gets the 409); agent-account-grant-refusal.test.ts, a second grant of a breaching set to an AI service account is the ceiling's 400 on insert and on a move (CONTROL: within the ceiling, the check's 409); stored-permission-set-package-backfill.test.ts, a dry run reports the would-be second grant as duplicated.
  • core/src/security/security-catalog.test.ts: holders on a refusal, in candidate order.
  • Dogfood: stored-reference-package-column.dogfood.test.ts's forged-column CONTROL asserts the engine now refuses the forge (F3) and writes it below the engine; upgrade-18x-index-equivalence.dogfood.test.ts pins the grant index's retirement on the restored 18.0 store as one safe replace.

Acceptance notes

  • Two-holder answers are unreachable on a real registry until objectql: narrow the phase-1 one-holder refusal from environment to namespace for permission set (ADR-0150 child B, permission set family) #31698 PR-2, so every two-holder test uses a stand-in registry; every one-holder answer is what a real environment answers today, and the md5 CONTROL below holds the measured ones byte-identical to main's door source (the environment's overlay row is the declared exception).
  • Structured candidates on /data are rest + types: the /data door's 409 RESOURCE_CONFLICT for a thrown package-scoped refusal carries details.candidates (ADR-0150 D5; sibling of #32201) #32250: the grant's refusal there carries the D3 sentence naming every holder, and the thrown error carries .refusal.candidates.
  • driver-memory, a declared limit. It builds no package-less guard (no raw-SQL seam; the activation ledger's guard has the same exemption). Its unique check treats a NULL as distinct, so the driver alone admits two package-less grants of one set for one user in one organization where the old key refused the second; through the engine the one-grant check refuses the second, as on every driver. memory-user-permission-set-packageless.test.ts asserts the driver's own admission, so a future guard there turns it red on purpose.
  • Upgrading SQL databases. The old index keeps refusing what it refused until a sync that runs safe drift (dev autoMigrate: 'safe', or os migrate apply) replaces it, after the guard stands. On an 18.0 database that index is in the NULL-safe COALESCE form: retired only on a certified table and kept everywhere else (P1). A MySQL or MariaDB store whose old key is carried by a hash shadow keeps the three-column key; the changeset gives the ALTER TABLE that retires it by hand once the guard stands (P1 keeps the carrier and offers nothing, never an orphan drop).
  • The ceiling's fail-closed read judges the holders the catalog serves (epic read 6817314015, S2): a disabled package's breaching set is not judged there, the same answer main gives for one holder, and the clamp at evaluation still holds. Carried by plugin-security: the AI-account ceiling refusal judges served holders only, so a disabled package's breaching set can be granted to an AI account #32600 (blocked by this PR).
  • A real mongod was not run (Verification): no CI lane runs the driver-mongodb real-server suites, and this container could not obtain a mongod binary.

Verification

Measured on 085991120 (this round's commit over the merge of main at 371bd8d45, main 3452dec21), on a shared container. Every suite ran under scripts/pm/os-verify-lock.sh, which excludes other locked runs only, so no wall-clock figure here is an idle-box reading. The workspace was rebuilt after the merge (turbo run build, 119 tasks).

Suites, types, lint

  • pnpm --filter <pkg> exec vitest run on 085991120, exit 0 each:
    • @objectstack/plugin-security: 262 files; 6460 passed, 45 skipped.
    • @objectstack/runtime: 586 files; 11283 passed, 19 skipped.
    • @objectstack/plugin-auth: 264 files; 4874 passed, 10 skipped (the first-party grant writers).
    • @objectstack/core: 153 files, 3429 passed.
    • @objectstack/dogfood: 451 passed, 3 skipped files; 3781 passed, 42 skipped (the dogfood battery the ! title owes).
  • Not re-run this round (no source of theirs changed since; CI ran them on this head): @objectstack/metadata-protocol (ec39bd8e5's tree: 22719 passed, 36 skipped), @objectstack/driver-memory (1785 passed), @objectstack/driver-mongodb (753 passed, 201 skipped: the real-server suites skip, no mongod), @objectstack/cli (7868 passed, 10 skipped).
  • pnpm --filter <pkg> run typecheck for plugin-security, runtime and dogfood: exit 0 each.
  • pnpm lint (eslint over the whole repository): exit 0.

Ablations. Each fix was reverted alone and its pins went red, then restored. This round: the one-grant check's stand-down on a holder the caller cannot read (2 failed); its priority after the agent-account refusal (1); the dry run's prediction (1); the environment's sentinel row as its own (4), and its re-address by id (4). Earlier rounds, unchanged code: E1, the sole-holder take-over (6); should-fix 2, the rule's holder count (1); E2, the one-grant check (4), the backfill's per-row catch (2) and the check's own-id exclusion (1); E3, the grant's package read (3) and the fail-closed read (1); should-fix 3, the closure restriction (1).

Gates. node scripts/pm/dispatch-gates.mjs --commands then --ran on 085991120: 116 derived, 116 run, 0 NOT-MEASURED, every one exit 0. Also run, exit 0 each: check-published-list-mirrors, check:changeset-fixed, check:error-code-casing, check:filter-alias-parity, check:published-readme-exports, check:route-ledger-census, check-system-context-census, check:platform-checklist, check-commit-card-trailers --range origin/main..HEAD and check-governed-merges --branch (0 of 51 paths governed; 2920 changed lines, under 3000).

md5 one-holder CONTROL, re-measured on 085991120. The same scratch probes as the earlier rounds: all 19 rows hash exactly as on ec39bd8e5 and aa4c4c413, whose 17 one-holder rows matched main's door source byte for byte, and the 2 sensitivity rows still differ. Not in that table, and declared in the changeset: the row the activation door writes for an environment-authored overlay set names no package where main wrote sys_metadata; every reader reads both as no package.

Answer BASE (main's door source) HEAD
X1 activation ?package= (must differ) 45a861dd (388 B) 46eaaf85 (424 B) DIFFERS
D1 to D8, activation (8 rows) as in the first round identical same
C1 to C5, clone (5 rows) as in the first round identical same
X2 grant, two holders, no package (must differ) 4097dabb (766 B) 606bf40b (606 B) DIFFERS
G1 to G4, grant (4 rows) as in the first round identical same

Live servers (local, this container).

  • MariaDB 10.11 and PostgreSQL 16, @objectstack/metadata-protocol's guard suites (user-permission-set-packageless-index.live-mysql and .live-postgres) with the store-alone rows, on ec39bd8e5 (unchanged since): 2 files, 7 tests passed.
  • First round, unchanged code paths: the real grant object booted fresh and upgraded from main's key on both servers (2 of 2 each), and the 8 live files of metadata-protocol (45 tests).
  • MySQL 8.0: not run here. P1b's guard suites passed on MySQL 8.0 in main's CI (run 43132792602); this PR's key and guard together first run there on main's push (Temporal Conformance is skipped on a pull request).
  • Real mongod: NOT MEASURED. The proxy answers 403 for fastdl.mongodb.org, there is no docker daemon, and this session's permission policy refused the other routes to a binary. No CI job runs driver-mongodb's real-server suites either (OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1 is set nowhere). The changeset says "pinned, not run" for Mongo accordingly.

Observation, not this PR's. @objectstack/driver-sql's live suite against MariaDB 10.11 (OS_TEST_MYSQL_URL), first round: 44 failed, 6297 passed, 4 skipped tests; 18 files failed, all in live mysql / live MySQL describes (JSON column type, hash-shadow and NOW-precision readings among them). driver-sql is byte-identical to main on this branch, and no CI lane runs MariaDB; CI's MySQL lane is 8.0. No card.

CI. On ec39bd8e5: 72 check runs, 60 success, 12 skipped, 0 failed (read by the delta review, 6817263634; the body edit re-ran four jobs). The skipped ones are skipped by their own condition on a pull request: the Dogfood shard matrix, Temporal Conformance, the 18.x upgrade harness, Dogfood Verify CLI, the packed-tarball smoke, the two queue verdicts, the schedule gate, Lint UI, UI Published Artifacts, and a re-run each of Auto Label and Check PR Size. On 085991120: 68 check runs, 58 success, 10 skipped, 0 failed, read before this body edit; the skipped ones are the same jobs as on ec39bd8e5 without the two re-runs.

Follow-ups

Filed by the seat; none is this PR's to fix.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (No spec change.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes. (pnpm lint: exit 0.)
  • 4. One issue, one branch off an up-to-date main, one pull request. (Three PRs for one card by the seat's split ruling; this is the last.)
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (Not touched.)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English. (## 维护者速读 is the maintainer's Chinese summary the dispatch rules ask for.)
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Utp6CZ9zs2B33JD9Z2RiDE

…set grant, clone and activation doors take a package; a grant is unique per package

ADR-0150 D3/D5/D8, child of #31527, ahead of #31698 PR-2.

- Grant (`sys_user_permission_set`): a set two packages hold, named with
  no package, is refused 409 RESOURCE_CONFLICT with the D3 sentence
  (core `PackageScopedNameRefusedError`); `permission_set_package` naming
  a holder is kept; naming a non-holder is refused at any holder count,
  never re-stamped (F3, declared narrowing).
- Activation door `POST /security/_activation/permission/:name` and clone
  door `POST /security/permission-sets/:name/clone` take `?package=`
  (clone: the SOURCE's package); two holders and no package answer 409
  with `details.candidates`; the activation door writes the named
  package's ledger row; query sets are closed (400); a clone target name
  two packages hold is taken (409).
- core: the refused arm of `SecurityCatalogResolution` gains an additive
  `holders` member (structured candidates).
- The grant's key is `(user_id, permission_set, permission_set_package,
  organization_id)`, `unique: 'global'`; package-less grants are kept to
  one per (user, set, organization) by a runtime guard
  `uniq_sys_user_permission_set_packageless` (metadata-protocol at
  kernel:ready for SQL, driver-mongodb runtime-owned unique). The guard is
  re-runnable, guesses no package and names the duplicated keys when it
  cannot build. The old three-column index retires only once the guard
  stands. driver-memory: declared limit, pinned.
- One holder: every door answers as before.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…L-safe narrower key, never an orphan drop

The full dogfood battery on the first head found three reds, each a
consequence of this change:

- An 18.0 database holds the grant's old key as
  `UNIQUE (user_id, permission_set, COALESCE(organization_id, '__global__'))`
  under the same generated name. The widening arm matched only a plain
  narrower, so the differ reported it as an orphan `drop_index`
  (destructive). It now matches the NULL-safe form too: kept until the
  guard stands on a certified table and then one safe replace; kept and
  never offered on a table that is not certified, where the COALESCE
  bucket still keeps NULL-organization rows to one.
- A plan read before the sync that adds a gained column (`os migrate plan`
  over an upgraded database) left the narrower to the orphan arm. A gained
  column not yet physical now keeps it.
- The stored-reference dogfood forged `permission_set_package` through the
  engine; F3 refuses that now. The CONTROL asserts the refusal and forges
  the value below the engine.

The equivalence dogfood pins the grant index's retirement as one safe
replace on the restored 18.0 store.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…ow; the doors test's engine double is ledgered

system-context.mdx gains row 23h for stored-permission-set-package-stamp.ts#onUpdate,
whose system-only read of permission_set_package judges a column-only bind (kept for
a holder, 409 not-held-by-qualifier otherwise), and row 23c names the grant's 409
refusals; the site register is regenerated. engine-double-contract.pinned.json records
the update double permission-set-package-doors.test.ts pins.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…g)`, the widening and three narrowings listed FROM → TO below it

Seat 3's ruling on the declaration line: the door widening, F3 (the doors'
?package= naming a non-holder included), the closed query sets and the
two-holder clone target each get one FROM → TO line under the arm.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 10, 2026
@github-actions

github-actions Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/metadata-protocol, @objectstack/plugin-security, @objectstack/runtime, touching 80 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/metadata-protocol/src/migrations/user-permission-set-packageless-index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

75 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json c4b6160d21b7a4e65393a378c6cd7ddb9533bf13.

⛔ 15 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/metadata-protocol/src/migrations/user-permission-set-packageless-index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 40 pages)
  • 10 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 65 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c4b6160d21b7a4e65393a378c6cd7ddb9533bf13 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ff397fbb87b796d1d6b6010f2db053694700205e — the merge of head 6075ae7189c8ac7ae5ac03e40694f4a1b2dd8d06 into base c4b6160d21b7a4e65393a378c6cd7ddb9533bf13, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ff397fbb87b796d1d6b6010f2db053694700205e && git checkout ff397fbb87b796d1d6b6010f2db053694700205e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c4b6160d21b7a4e65393a378c6cd7ddb9533bf13 6075ae7189c8ac7ae5ac03e40694f4a1b2dd8d06 && git checkout -B drift-repro c4b6160d21b7a4e65393a378c6cd7ddb9533bf13 && git merge --no-ff 6075ae7189c8ac7ae5ac03e40694f4a1b2dd8d06

node scripts/docs-audit/affected-docs.mjs --json c4b6160d21b7a4e65393a378c6cd7ddb9533bf13

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c4b6160d21b7a4e65393a378c6cd7ddb9533bf13 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-support-ai · session_01WGufzeeBmtefMTT21doena

Contract-review-claim: session_01WGufzeeBmtefMTT21doena · head 72f0a68 · 2026-11-10T17:38Z

Face: narrowing, at CONTRACT_REVIEW_TIER, by an isolated sub-agent of this seat.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-support-ai · session_01WGufzeeBmtefMTT21doena

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 72f0a6848aa479032d745bce37e08040fa4ced2e
Local-runs: none
Scope: every non-prose path of the diff — packages/core/src/security/security-catalog.ts, security-catalog.test.ts; packages/plugins/plugin-security/src/grant-permission-set-name.ts, grant-permission-set-name.test.ts, grant-permission-set-package.test.ts, granted-by-writer-provenance.test.ts, stored-permission-set-package-stamp.ts, stored-permission-set-package-backfill.test.ts, objects/sys-user-permission-set.object.ts; packages/runtime/src/domains/catalog-activation.ts, permission-set-clone.ts, security.ts, permission-set-package-doors.test.ts; packages/metadata-protocol/src/index.ts, plugin.ts, migrations/user-permission-set-packageless-index.ts (+ .test, .live-postgres.test, .live-mysql.test), migrations/activation-ledger-packageless-index.ts, migrations/runtime-index-preflight.ts (+ .test); packages/drivers/driver-sql/src/schema-drift.ts, sql-driver-widened-grant-key.test.ts; packages/drivers/driver-mongodb/src/mongodb-schema.ts, mongodb-user-permission-set-packageless.test.ts; packages/drivers/driver-memory/src/memory-user-permission-set-packageless.test.ts; packages/cli/src/commands/migrate/duplicates.contract.test.ts; packages/types/src/driver-error-classification.callers.test.ts; packages/qa/dogfood/test/stored-reference-package-column.dogfood.test.ts, upgrade-18x-index-equivalence.dogfood.test.ts; scripts/engine-double-contract.pinned.json. Prose, judged for accuracy: the changeset and the three content/docs/permissions pages. + read, not reviewed: packages/objectql/src/registry.ts (packageScopedHoldersOf, getPackageScopedItem, getEnvironmentScopedItem), packages/core/src/security/resolve-authz-context.ts (readDisabledCatalogNames, ledgerRowHolders, catalogEntryAddress), packages/core/src/utils/metadata-activation-store.ts, packages/core/src/kernel.ts (phase order), packages/plugins/plugin-security/src/stored-permission-set-package-backfill.ts, delegated-admin-gate.ts, auto-org-admin-grant.ts, objects/sys-audience-binding-suggestion.object.ts, packages/cli/src/utils/stored-package-references.ts, packages/rest/src/query-allowlist.ts, packages/drivers/driver-sql/src/sql-driver-schema-sync.ts and sql-driver-column-creation.ts, docs/adr/0150 (D3/D4/D5/D8, red line 3, execution plan), AGENTS.md (route rule 5, changeset rule), .github/workflows/ci.yml (job conditions). Not read: core's package-scoped-resolution module itself, partial-index-probe.ts, and any ADR on destructive schema changes (none located).

Check-runs on this head (last poll): no failure. Pending: Build Core, Build & E2E UI, Test Core 1/8 to 8/8, Test UI 1/16 to 16/16, Type Check · workspace, Type Check UI. Green: Check Changeset, PR-body guards, all Lint jobs, Type Check source/consumer/debt gates, Governed Surface Queue Guard, Spec property liveness, doc links, PR size. Skipped by job condition, so they are no verdict here: the three Dogfood shards (PR events are excluded, which makes the green "Dogfood Regression Gate" vacuous on this head), "Temporal Conformance (live PG + MySQL)" (excluded on pull_request and merge_group, and it is the only job that runs the live-mysql / live-postgres files), "18.x upgrade harness (opt-in)".

① Derived judgments

  • Doors take a package (F1): right. permission_set_package on the grant, ?package= on clone (source) and activation. Both doors resolve a named package against the catalog's per-holder list, never the stale-column arm, which is what step 0 said the seam must do. One-holder answers keep their path.
  • F3: right in behaviour, incomplete in declaration (must-fix 2 below). assertNamedPackageHolds refuses at any holder count, insert and system update, and never re-stamps.
  • Closed query sets: right. Read after authority and shape, before body and lookup; uses rest's unknownQueryParamRefusalMessage; own-property read; repeat and blank refused.
  • Clone target taken: right. resolveCatalogReference(...).status !== 'not-found'; a thrown read still answers 503.
  • Unique index: the ruled route, built as ruled.
    • SQLite and PostgreSQL: partial index on the old three columns WHERE permission_set_package IS NULL. Condition 1 holds for a non-null and a NULL organization (measured by the dev on SQLite and PG16).
    • MongoDB: RUNTIME_OWNED_UNIQUES entry with permission_set_package: null, which matches a missing field too, so legacy documents count as package-less; organization exempt through the non-null type list, as under the old key.
    • driver-memory: condition 3 met — measured, declared in changeset and PR body, admission pinned.
    • MySQL and MariaDB forms copy the ledger precedent but have never run (should-fix 5).
  • Migration and preflight: right. Re-runnable on every dialect arm (IF NOT EXISTS, ER_DUP_KEYNAME, ER_DUP_FIELDNAME then key). It guesses no package and writes no row. On duplicates it returns conflict, logs the keys and the listing query at error, and leaves the old key standing. The preflight roster entry reuses the same probe SQL.
  • Schema-drift arm: right on what was measured, wrong on what was not. On a SQLite 18.0 store the COALESCE narrower is kept until the guard stands, then one safe replace on a certified table only; uncertified it is kept and nothing offered. The absent-gained-column rule is safe and generic. The criterion "no destructive drop_index is ever offered on an 18.0 store" does not hold for a shadow-carried key (must-fix 3).

② Semver level

patch on six packages with the ! title and BREAKING banner is the level AGENTS prescribes for a non-protocol breaking change. Clause-②: no (narrowing) and Metadata-protocol: none are line-initial in both bodies; none is correct (a platform object's indexes[] and a data column are not authored metadata). The ADR-0087 marker not-required (no-migration-prescription) is present and apt. The new metadata-protocol exports mirror the ledger's five plus two types, are additive and declared; core's optional holders is additive and declared. Nothing new is re-exported from the runtime or plugin-security index.

FROM→TO completeness fails in three places:

  • must-fix 2 — the suggestion table (below).
  • must-fix 3 — the grant's shadow-carried key is listed under "Unchanged".
  • should-fix 4 — a system insert naming a package for a name nothing holds, after the seal: FROM replaced by none TO kept as written. The module doc says it; the changeset lists only the pre-seal case.
  • nit — the BREAKING banner says "either permission-set door", but the activation door now refuses a query on all six types; the bullet below it is correct.

③ Boundary flags

  • Ledger stays name-keyed as a BOUNDARY: not accepted as written — must-fix 1, and escalate to the epic seat.
    • The stated reason, "every reader of a permission set's row is name-keyed", is false. readDisabledCatalogNames → ledgerRowHolders reads the row's package_id and disables only that holder (ADR-0150 D8 R4; its own comment: switching off one package's set "leaves another package's … granting").
    • setActive finds by (metadata_type, name) and updates package_id in place. So flip HR off, then flip CRM off (or on): HR's row is gone and HR's set grants again, both calls answer 200.
    • The doors test pins exactly this as intended (BOUNDARY — one ledger row per (type, name) expects [[CRM, false]]).
    • The changeset, the door's jsdoc and authorization.mdx all say "re-addresses that row" without the consequence. M4 on the card asks for "that package's ledger row".
    • Unreachable until objectql: narrow the phase-1 one-holder refusal from environment to namespace for permission set (ADR-0150 child B, permission set family) #31698 PR-2, but this card is PR-2's precondition. Minimum here: correct the false reason and state the consequence in all three texts; and either keep per-package rows for permission (a core store change, needs a surface amendment) or have the door refuse a flip that would re-address a switched-off row naming another holder. The epic seat rules which, and PR-2 is blocked on it.
  • MySQL/MariaDB hash-shadow item kept out of scope: not accepted as "as on main" — must-fix 3, and escalate.
    • The differ rule is unchanged, but the outcome is new: on main the three-column key is declared, so a shadow-carried index matches by name and is no drift. This PR's respelling is what makes it an orphan drop_index, categorized destructive.
    • The changeset files it under "Unchanged: … still not matched by this arm" and never states the outcome. The PR body's 维护者速读 says the old index is never dropped as an orphan, which its own Acceptance note contradicts.
    • How common it is, is unmeasured. The dev's live-mysql fixture says driver-sql keys this table through a hash shadow on MySQL; my read of column creation is that lookup columns are varchar(255), so a fresh three-column key is directly keyable and the shadow is the legacy-TEXT case. Nobody has run it.
    • Minimum: declare it FROM→TO in the changeset and fix the 速读 line. Recommended in this PR: account for a shadow-carried narrower as kept, nothing offered — the same disposition the uncertified COALESCE case already gets. Otherwise a filed card with a live MySQL measurement before release.
  • F3 reaching the doors: accepted. ?package= naming a non-holder is 409 at any holder count, declared and pinned, with a one-holder CONTROL.

④ Design judgment

must-fix

  1. Activation ledger silent re-arm and false rationale (③, first flag).
  2. F3 also moved sys_audience_binding_suggestion (R3), undeclared and unpinned. The stamp seam registers on both STORED_PERMISSION_SET_REFERENCES, so a system insert or column-only update on the suggestion table naming a non-holder moved from re-stamped / landed-as-written to 409. os migrate stored-references bind --table sys_audience_binding_suggestion writes through that hook. The changeset, PR body and system-context row 23h all say "the grant"; no test exercises R3 with a named package. The object is system-write only and first-party writers send no package, so the fix is one FROM→TO line in both bodies and one pin.
  3. Shadow-carried grant key becomes a destructive orphan (③, second flag).

should-fix
4. Undeclared FROM→TO for a system insert naming a package on an unheld name (②).
5. "Measured per dialect" overstates. The MySQL 8 functional form, the MariaDB INVISIBLE form, the MySQL recognition of the 18.0 functional COALESCE narrower, whether hasPartialUniqueGuard reads the MySQL functional guard as a guard, and the real-mongod suite have run nowhere; their job is excluded on PR and merge-queue events, so they first run on main after landing. The dogfood battery verdict is the dev's local run on ae6343780, two commits before head. Say "pinned" where it is pinned and "measured" only for SQLite and PG16.
6. Unguarded window on a fresh database. The guard is built at kernel:ready. Before the seal every grant is stamped package-less, and the platform sets always are, so on the first boot of a fresh SQL database (no old key to fall back on) the declared key constrains none of the bootstrap grants. Writers such as auto-org-admin-grant.ts are find-then-insert and their own comment leans on the unique index; two replicas booting together can now both land. A host with bootPhaseHooks: false never builds the guard. The consequence is loud (guard conflict at error each boot) but it is a case the old key refused, so it bears on epic condition 1 and should be declared or closed by building the guard at schema sync. Escalate with must-fix 1.
7. Overlay sentinel not normalised. judgeGrantSet and resolvePermissionSetAddress compare and report entry.packageId raw, where core's catalogEntryAddress maps the sys_metadata provenance sentinel to "no package". Where an entry carries it, a refusal lists package "sys_metadata" instead of the environment, ?package=sys_metadata resolves at the doors, and the grant hook and the seam disagree (hook passes, seam refuses).
8. Mongo duplicate path unpinned. createIndex on existing duplicates throws E11000 out of syncCollectionSchema; the changeset's "fails and logs … the next boot tries again" describes the SQL arm only. A server that refuses a second index on the same key pattern answers code 85, which the loop skips silently, leaving the guard absent and the old key kept.

nit

  • judgeGrantSet and resolvePermissionSetAddress are one function in two packages; it belongs in core beside resolveCatalogReference.
  • permission-sets.mdx states "at most one grant that names no package" without the driver-memory limit.
  • system-context row 23c still ends "names its set by name alone".
  • The permission_set_package field description still says only the platform fills it.
  • The QA checklist item for these two doors is not updated.

Tried and held: two holders with and without a package at all three doors; one-holder CONTROLs; a package-less legacy row (Mongo missing field, SQL NULL); the migration run twice per dialect arm; an upgrade with duplicates (refused, named, no row touched, old key kept); a concurrent grant after the guard stands (the database refuses it); ADR-0112 pairs (409 RESOURCE_CONFLICT, 400 VALIDATION_ERROR, 404 and 503 unchanged); a non-system update's package (stripped, never judged); the delegated-admin gate (it already reads the row's package, and the seam now refuses the stale case it would have re-read). The roster pins are hand-written lists, as their precedents are, not iterations of a live set.

Implemented-by: claude/project-thread-ekmc12
Reviewed-by: session_01WGufzeeBmtefMTT21doena

VERDICT: FAIL

…carries (MySQL, MariaDB), never an orphan drop

A #11627 shadow-carried narrower whose resolved key is exactly the superseded
columns (any NULL-safe part the tenant column's) is accounted for by the
widening arm and kept, with nothing offered: replace_unique_index drops the
legacy name and retires no shadow. Before, it fell to the orphan arm as a
destructive drop_index once the declaration was respelled (contract review
must-fix 3 on #32306). Pinned at the differ, plain and NULL-safe, certified
or not, with and without the guard, plus two CONTROLs.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…e-less guard is built at schema sync, an old key kept where it has no form, and a refused Mongo guard said at error

ObjectQLPlugin triggers `objectql:schema-synced` when each schema-sync pass's
DDL is done (OBJECTQL_SCHEMA_SYNCED_HOOK), and metadata-protocol builds
`uniq_sys_user_permission_set_packageless` from it, so the guard stands before
any later plugin's start() writes a grant, on a `bootPhaseHooks: false` host
too. The kernel:ready build stays as a second chance and is a no-op once a pass
settled it.

On a server with no guard form (MySQL < 8.0.13, MariaDB < 10.3) the old
three-column key is kept, or built under the driver's generated name where
missing, so the widening arm keeps it until the guard exists.

driver-mongodb reports a runtime-owned unique the server refuses (E11000, 85,
86) at error with what lists or names the conflict, and the sync goes on.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…older's own, and F3 at the suggestion stamp is declared and pinned

Contract-review fix round on #32306 (seat 3 and epic rulings):

- MF1: the permission activation store is addressed by (package, name),
  PERMISSION_LEDGER_ADDRESSING. Flipping one holder never touches the
  other's row, and both stay switchable. A row naming no package, or a
  package that no longer serves the name, is the one holder's switch (taken
  over, D8); with two holders, switching one on while such a row keeps it
  off is 409 naming that row, nothing written, and a write that would
  re-address another holder's switched-off row is 409 too. The false
  "every reader is name-keyed" rationale is corrected in the door's doc,
  authorization.mdx and the changeset.
- MF2: F3 at sys_audience_binding_suggestion (R3) is declared in the
  changeset and system-context row 23h, with one pin.
- SF4: a system insert naming a package for a name nothing holds is kept
  as written; declared and pinned.
- SF7: judgeGrantSet and resolvePermissionSetAddress read the
  'sys_metadata' sentinel as the environment, pinned.
- The grant's permission_set_package description, its translations, the
  driver-memory limit in permission-sets.mdx, row 23c's ending, and the
  changeset trimmed to this PR's packages (the widening arm and the guard
  are the two PRs before it).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…ckage-less guard, six runtime indexes

The roster gained uniq_sys_user_permission_set_packageless (#32164), so the
booted run reports six runtime indexes, the sixth by its migration name.
Test Core (8/8) on 72f0a68 caught the stale count.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…eps the logger's receiver

(logger.error ?? logger.warn)(...) read the channel off the logger and called
it detached, so a class logger (core's ObjectLogger) lost `this`. Called
through the property now; check:logger-receiver-detach caught it. The P1b
changeset gains the blank line before its Metadata-protocol line.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…index, not Array.prototype.at

metadata-protocol's test layer compiles against a lib without Array.prototype.at,
so its typecheck (CI Type Check · workspace on 58f31b6) refused ran.at(-1).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…rier under the superseded key's name, never an orphan drop

A #11627 carrier whose generation expression could not be read sits under
the narrower key's generated name, and the orphan arm does not skip an
unresolved carrier: it offered it as a destructive drop_index, with and
without the guard, certified or not, and beside the wider key's
create_index when the gained column was absent. What it hashes is unknown,
so the widening arm now keeps it and offers nothing (contract review
must-fix 1). Pinned at the differ; the claim-loop comment and the
changeset are corrected, and the changeset states how many first-party
declarations this arm reads on main.

The absent-gained-column term of the kept rule gets a pin of its own
(should-fix 3): a plain narrower read before a REQUIRED gained column is
physical is kept with only the wider key's create_index offered. Turning
that term off had left every pin green; it now turns this one red.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…the table's DDL on every kernel, re-ensured after each pass

The package-less grant guard was subscribed inside the runPlatformMigrations
gate, so a kernel with an environmentId and nothing declared, and every
one-shot CLI boot, created sys_user_permission_set at schema sync with no
guard (contract review must-fix 1). The objectql:schema-synced subscription
now sits outside the gate; the kernel:ready build stays a gated repair.

A pass whose driver holds DDL back (os migrate plan, every dry-run mode,
os migrate apply before its confirmation) builds nothing: the ensurer reads
the driver's schemaDdlDeferred. The CLI's flush triggers the event again
once it has performed what was held back, so os migrate apply builds the
guard. A write mode that boots without deferring builds it at its sync.

A built guard is no longer latched: every later pass ensures it again
(IF NOT EXISTS), because a SQLite table rebuild drops it. Only a refusal
is latched, so it is reported once per boot (should-fix 2).

Wording that was true only once the grant key is widened now says so, and
the Mongo options-conflict line no longer says the key is not enforced when
the index in the way may be an older unique on the same fields.

The stored-references fixture that rebuilds a database the release before
D8 left also drops an index naming a package column in a partial WHERE: its
template's write boot now builds the guard, and SQLite refuses to drop a
column such an index still reads.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…the three-column key, with the remedy by hand

A MySQL or MariaDB store whose old grant key is carried by a hash shadow
keeps it: the widening arm offers nothing for a shadow carrier, so that
store keeps refusing one user a second package's set of one name in one
organization. The changeset now says so and gives the ALTER that retires
it once the package-less guard stands (contract review should-fix 2).

The guard migration's module doc states the grant's declared key as this
change makes it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-support-ai · session_01WGufzgBi8iNgKbhq9r7VDT

Hand-off: this round is pushed. Head 4b62e66fc9. Seat 3 has stood down, and no CR has run on these commits. Everything the next seat needs is in this comment.

What the three new commits carry

After #32356 and #32357 land, this PR's own diff is 1812 lines (+1686 / -126) in 40 files, measured over main 9bff554 plus both PRs' fixes.

Measured on 4b62e66fc9 (under the verify lock)

Step Result
build (turbo run build, docs excluded) 119/119 tasks
eslint over the changed .ts files exit 0
core 152 files, 3399 tests
runtime 583 files; 11052 tests passed, 19 skipped
plugin-security 260 files; 6266 tests passed, 45 skipped
metadata-protocol 333 files passed, 7 skipped; 22685 tests passed, 35 skipped
objectql 585 files, 12180 tests
service-automation 272 files, 3649 tests
driver-sql 250 files passed, 13 skipped; 4224 tests passed, 227 skipped
driver-mongodb 37 files passed, 6 skipped; 753 tests passed, 201 skipped
driver-memory 76 files, 1776 tests

NOT measured on this head (a container restart, then the stand-down)

CI on this push is the next reading.

What remains


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-support-ai · session_01WGufzgBi8iNgKbhq9r7VDT
os-dev-report
#32164 is now held by domain:server#6. This report is measurement only: I pushed nothing and changed nothing on any of the three PRs.

{
  "issue": 32164,
  "status": "measured",
  "held_by": "domain:server#6 (takeover 6788362280)",
  "branch": "claude/project-thread-ekmc12",
  "pr": 32306,
  "head_measured": "4b62e66fc9",
  "session": "session_01WGufzgBi8iNgKbhq9r7VDT",
  "premise_still_valid": true,
  "summary": "These are the four readings the hand-off (6788118653) left unmeasured on 4b62e66fc9: the whole cli suite, the dogfood battery, the md5 one-holder CONTROL and dispatch-gates --ran. All four are green locally. CI is red on one test, Test Core (7/8): the types callers ledger has 2 calls where the file now has 3. It reproduces locally, and #32357 carries the same defect (comment 6788459878). I did not fix it because the card is seat 6's. The head was measured as pushed, 4b62e66fc9 over base 9bff554ef, not merged with main.",
  "tests": {
    "cli": "@objectstack/cli, whole suite: 629 files passed, 2 skipped; 7842 tests passed, 10 skipped; exit 0",
    "types_repo": "@objectstack/types `vitest run --project repo`: 1 failed, 10 passed (11). It fails on src/driver-error-classification.callers.test.ts › 'the non-test callers are exactly the enumerated ones, call sites included', the same failure as CI Test Core (7/8)",
    "earlier_on_this_head": "These are in 6788118653: build 119/119, eslint exit 0, core, runtime, plugin-security, metadata-protocol, objectql, service-automation, driver-sql, driver-mongodb and driver-memory, all green",
    "control": "md5 one-holder CONTROL (main e40f2df2's door and grant source as BASE, this head as HEAD): 16 hashed answers, 10 door and 6 grant, each identical to BASE. These are the 14 rows of the PR body's CONTROL table, where two grant rows hash twice. The run printed 'DIFFERS' first because the probe appended to the previous run's output files. The last run's 10 + 6 rows match the BASE files exactly (diff empty). Probe cleanup left 0 changed paths"
  },
  "gates": "node scripts/pm/dispatch-gates.mjs --commands on 4b62e66fc9 derived 132, and each ran with its exit code captured: 132 of 132 exit 0. --ran: '132 derived, 132 run, 0 NOT-MEASURED, 0 UNRUN', exit 0. dispatch-gates does not derive @objectstack/types test:repo, which is why the ledger failure below got past the local verify",
  "dogfood_battery": "bash scripts/pm/os-verify-lock.sh -c 'pnpm --filter @objectstack/dogfood test', the whole suite: 449 files passed, 3 skipped (452); 3752 tests passed, 42 skipped (3794); step exit 0. The battery, cli, CONTROL and gates ran as one script under one lock hold: 'VERDICT command-exit 0 · held the lock 6749s', shared-box seconds. Each step's own exit code is recorded above",
  "findings": [
    {
      "what": "CI Test Core (7/8) is red on 4b62e66fc9, and #32357 (0e9ff2186d) has the same content",
      "evidence": "packages/types/src/driver-error-classification.callers.test.ts:445, the OPERATOR_TEXT_CALLERS row for packages/metadata-protocol/src/migrations/user-permission-set-packageless-index.ts, says calls: 2. The file has 3 operatorFacingErrorText( calls: line 360 (the conflict report), line 451 (the old-key failure) and line 434 (new). Line 434 is the catch in createUserPermissionSetGuardAtSchemaSync, logProblem(logger, '…skipped…', operatorFacingErrorText(err)), added by P1b round 2 (c51686cf5b here, 0e9ff2186d on #32357)",
      "fix_options": [
        "Set calls: 3 and add the pin the test asks for in user-permission-set-packageless-index.test.ts: make the schema-sync ensurer's seam throw an error carrying SENTINEL-21418-BOUND-VALUE, then assert the logged line does not contain it",
        "Route that catch through one of the two existing call sites, so the count stays 2"
      ],
      "owner": "domain:server#6, posted on #32357 as 6788459878. The fix belongs on #32357 first, then the same commit on this branch"
    }
  ],
  "pr_body_stale": "Unchanged from 6788118653. Seat 6 owns any update: 'built at `kernel:ready`' should now say at schema sync, on every kernel that runs the table's DDL. The hash-shadow acceptance note is superseded by #32356's fix and the changeset's declared limit. Verification predates this round; the readings above replace it (battery 449/3752, gates 132/132, cli 629/7842, CONTROL 16/16 identical)",
  "mcp_calls": "read-only GitHub MCP only (pull_request_read, get_check_run, get_job_logs, actions_get, issue_read); the exact count was lost across two context compactions; no MCP writes",
  "api_writes": {
    "count": 9,
    "endpoints": [
      "git push claude/project-thread-vpgku3 → 132e3656dd (#32356), at the user's order",
      "git push claude/project-thread-c1daza → 0e9ff2186d (#32357), at the user's order",
      "git push claude/project-thread-ekmc12 → 4b62e66fc9 (#32306), at the user's order",
      "POST issues/32356/comments 6788099539 (hand-off)",
      "POST issues/32357/comments 6788106892 (hand-off)",
      "POST issues/32306/comments 6788118653 (hand-off)",
      "POST issues/32164/comments 6788125958 (hand-off)",
      "POST issues/32357/comments 6788459878 (types-ledger finding for domain:server#6)",
      "POST issues/32306/comments (this report)"
    ]
  },
  "open_questions": [],
  "security_findings": "none",
  "out_of_scope_findings": [
    {
      "class": "observation",
      "what": "dispatch-gates --commands does not derive @objectstack/types test:repo for a diff that adds an operatorFacingErrorText( call outside packages/types, so the ledger miss reached CI",
      "reach": "this card: 132/132 local gates green while CI Test Core (7/8) went red",
      "carrier": "none"
    }
  ]
}

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#31527#1 · os-bill · session_01Utp6CYGYEUhpi29FpjLmxT

Epic red-line read at ec39bd8e56 · 2026-11-12T07:14Z

Head-sha: ec39bd8e5608c43a55e19e42e209b1580dc12163

PASS. E1, E2 and E3 from 6813516860 are fixed, pinned, and red under ablation. Every attempt to break the ceiling failed.

Method: the 19-file delta aa4c4c413..ec39bd8e5, the PR body and the changeset were read. In a separate worktree (OS_SKIP_DTS=1), the E1–E3 pin files, one ablation per fix, and scratch probes on a real SQLite engine were run.

  • Red line 1 (resolution never loosened, no ceiling bypass): holds.
    • E3: setBreaches reads the grant at its stored package and fails closed on a refused read (agent-account-grant-refusal.ts:242-251, :277).
      • The pins are 14 of 14 green.
      • Ablations: dropping the package read turns 2 red; making the refused read answer "no breach" turns 1 red.
    • Break attempts on a real engine, with the name hook, stamp seam and ceiling hook bound. CRM's set is within the ceiling and HR's is above it. Each attempt below was refused with nothing stored, unless marked:
      • no package: 409 naming both holders;
      • a padded, upper-cased, sentinel, non-holder or array package;
      • a mixed batch;
      • a move of a stored HR grant onto the account, with or without a sent CRM package;
      • a rename to a breaching set;
      • an uninstalled (stale) package;
      • a disabled breaching package: 409 not-held-by-qualifier;
      • a non-system update of the package column alone: admitted, with the column stripped back to CRM;
      • CONTROLs admitted: a grant naming CRM, and a stale column where neither set breaches.
    • E2: the seam refuses a second grant of one user, set and organization unless the two rows name two different holders (stored-permission-set-package-stamp.ts:303-330).
      • 108 pins are green.
      • Ablations: the seam check turns 5 red; the backfill's per-row catch (stored-permission-set-package-backfill.ts:314) turns 2 red.
      • Probes all answered 409 with nothing written: a session-derived organization, a system writer, a batch, a rebind, a nulled package, and a package-less third row.
  • Red line 2 (ambiguity reported, never guessed): holds.
    • A stale or sentinel row is never re-attributed under two holders.
      • A disable writes only the holder's own row.
      • An enable, a sentinel row, and a disabled second holder each answer 409 with zero writes.
    • The environment beside a disabled package takes over the stale row. That is the resolver's own answer (package-scoped-resolution.ts:620-623), not a guess.
    • The ceiling's refused read judges every served holder and never picks one.
  • Red line 3 (independently releasable): holds.
    • E1: the environment as sole holder takes over a stale or sys_metadata row, on enable and disable (catalog-activation.ts:499, :516).
      • The pins are 32 of 32 green.
      • Ablation: restoring the target !== null gate turns 6 red.
    • Every moved answer in the delta is in the changeset and the body, with its arm and a pin:
      • the ceiling narrowing;
      • outside-closure;
      • the disabled-holder count;
      • the backfill's duplicated report;
      • the environment row written package-less where main wrote sys_metadata.
    • Class sweep: these all read by (package, name), the four-column key, or the stored column:
      • the name hook and both doors;
      • the seam and the backfill;
      • the ceiling;
      • the uninstall cleanup (cleanup-package-permissions.ts:244-249);
      • the production seam binding (security-plugin-start-hooks.ts:139).
    • CI at the head: 72 check runs, 60 success, 12 skipped, 0 failed.

Should-notes (not blocking):

Not run: full suites, typecheck, lint, live PostgreSQL, MySQL, MariaDB and mongod, and a main build. S2's one-holder parity is read from main's source, not run.

…grant table's refusals; the environment owns its sentinel ledger row

Delta review 6817263634 (should-fix A and nits) and epic read
6817314015 (S1) on #32306.

- The one-grant check is its own pair of hooks at priority 50, after
  every refusal of the grant table's hooks (30, 35, 40, 45), so a second
  grant one of them refuses keeps that answer. It steps aside, as they
  do, on a holder a non-system write names that the caller cannot read:
  the engine's reference check answers it (#29029). Record validation
  still runs after every hook; that precedence is declared.
- The check's reads and predicate live beside the backfill, which a dry
  run now asks, so it reports a would-be second grant as `duplicated`,
  not `bound`.
- The refusal's cause names no index; the module doc no longer lists the
  same package among the pairs neither index sees.
- Activation door: a row stamped with the overlay sentinel is the
  environment's own switch where the environment holds the name beside a
  package, re-addressed by id, never doubled; the package-less row's
  sentence says when it switches off every holder.
- Changeset: the precedence move, the backfill's FROM against main, the
  environment-beside-a-package rows. Docs and system-context row 23h.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CZ9zs2B33JD9Z2RiDE
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#31527#1 · os-bill · session_01Utp6CYGYEUhpi29FpjLmxT

Epic red-line read at 0859911208 · 2026-11-12T09:20Z

Head-sha: 0859911208fece35edb5476fb47be928c1d92272

No PASS. One moved answer is declared but not pinned (F1, red line 3). Red lines 1 and 2 hold, and S1 and should-fix A from 6817314015 and 6817263634 are fixed, pinned and red under ablation.

Method: this is a delta over the PASS at ec39bd8e5 (6817314015). The merge 371bd8d45 and the fix commit 085991120 (11 files) were read, with the changeset and docs. In a separate worktree (OS_SKIP_DTS=1 build), the touched suites, seven ablations (one source change at a time, each restored) and one scratch probe on a real SQLite engine were run.

  • Merge 371bd8d45 (main 3452dec21): clean.
    • Its combined diff is empty.
    • Its tree equals a fresh re-merge of the two parents (c69a0ea44).
  • Red line 1 (resolution never loosened): holds.
    • The one-grant check runs at priority 50, after the grant table's refusals at 30, 35, 40 and 45 (stored-permission-set-package-stamp.ts:158, :431).
    • It stands down only for a non-system write naming a holder the caller's own read does not return (:327).
      • The engine's reference check then refuses that write: 400 VALIDATION_FAILED, reference_not_found at user_id, nothing stored. Pinned for insert and update in every store.
      • A system writer is never stood down. A dispatch with no caller api keeps the check.
    • Ablations:
      • priority 20 or 40: 1 red (the ceiling's 400 becomes the check's 409);
      • stand-down removed: 2 red (the 409 returns for an unreadable holder).
    • CONTROLs are green: a readable holder's second grant is 409 DUPLICATE_RECORD, and so is a second grant within the ceiling.
  • Red line 2 (ambiguity reported, never guessed): holds.
    • A row is the environment's own only when the readers read it as no package, which is the overlay sentinel (catalog-activation.ts:521). For a package target the new match adds nothing.
    • The re-address is by id, on that one row, with the store's own update shape (:524, :886). A row naming no package is matched first, so the row is never doubled.
    • The check that a row would switch off another holder still runs on the re-addressed row.
    • Ablations: dropping the reader-style match turns 4 red; dropping the re-address by id turns 4 red.
    • CONTROL is green: ?package= the package writes its own row and leaves the sentinel row untouched.
    • A dry run of the backfill reports a would-be second grant as duplicated, with the write's own predicate (stored-permission-set-package-backfill.ts:339). Ablation: 1 red.
  • Red line 3 (independently releasable): not met, on F1 only.
    • Declared with FROM → TO under Clause-②: no (narrowing), and pinned:
      • the environment beside a package (the sentinel row re-addressed to no package);
      • the backfill's duplicated row, with its FROM against main.
    • The dry run's duplicated report is declared and pinned.
    • One-holder CONTROLs are unchanged and green.
    • Touched suites: plugin-security 162 of 162 (6 files), runtime 118 of 118 (3 files).
    • CI at the head: 72 check runs, 60 success, 12 skipped, 0 failed.
    • K = 12. The head merges with origin/main (1216abf1a) without conflict.

Finding (blocking):

  • F1. The changeset declares a move that no test pins (.changeset/32164-permission-set-doors-take-a-package.md:18, :68).
    • The move: a second grant whose row record validation would also refuse, FROM 400 VALIDATION_FAILED TO 409 DUPLICATE_RECORD.
    • It is real. Probe on a real engine: a second grant with a 600-character reason answers 409 for a system and a non-system writer; with the seam's reads off it answers 400; the invalid row alone answers 400. Nothing is stored in any case.
    • Fix: add that case to grant-permission-set-package.test.ts (the [E2] block, :206), with BASE 400 and the invalid-row-alone CONTROL.

Should-notes (not blocking):

Not run: full suites, typecheck, lint, live PostgreSQL, MySQL, MariaDB and mongod, and a main build. The FROM side of F1 is measured with the seam's reads off, not on main.

…ecedence and the activation door's package-less sentence

Round 4 of #32164 P2 (PR #32306), after the epic red-line read of
0859911 (6819720516) and the contract delta review r3.

- [E2, red line 3] A second grant of one set to one user in one
  organization whose row record validation also refuses (`reason` past
  its maxLength) answers the one-grant check's 409 DUPLICATE_RECORD for
  system and non-system writers, where the store's key answered 400
  VALIDATION_FAILED after validation; CONTROL: the same invalid row with
  no grant beside it is still 400.
- [S3] Beside the environment's own ledger row, a switched-off row
  stamped with the overlay sentinel keeps the environment's set off:
  the 409 reads that row by the name alone, never "every holder".
- The changeset says the check, at priority 50, also answers before a
  grant-table hook at the default priority (100), an app's own included.
- Withheld; a security item is with the owner seat.

Merges origin/main at 9b434a8.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CZ9zs2B33JD9Z2RiDE
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#31527#1 · os-bill · session_01Utp6CYGYEUhpi29FpjLmxT

Epic red-line delta read at 6075ae718 · 2026-11-12T09:50Z

Head-sha: 6075ae7189c8ac7ae5ac03e40694f4a1b2dd8d06

PASS. This is a delta read over No PASS 6819720516 at 0859911208, read by the seat. Nothing was run; the owner seat's contract delta review covers the runs.

  • Delta. Two merges of main (through a8c761fbe, fix(adr-0087): refuse not-required (no-migration-prescription) on a declared Metadata-protocol: narrowed #32649 included) and one commit, b38e2ec6b: 5 files, +51/−4.
  • F1 of 6819720516: closed. grant-permission-set-package.test.ts pins the second grant whose row also fails record validation. It answers 409 for system and non-system writers, with the CONTROL (the invalid row alone stays 400).
  • S3: closed. permission-set-package-doors.test.ts pins the environment's ledger row beside a switched-off sentinel row. The 409 reads it by the name alone.
  • Changeset. One more move is declared beside F1's: a second grant that an app's grant-table hook at the default priority (100) would refuse answers the check's 409 (the check runs at 50). It is refused either way and nothing is stored, so red line 1 holds. No pin covers an app hook specifically, which is acceptable as a declared ordering.
  • One source line in holderLeftToEngine (stored-permission-set-package-stamp.ts:326). It is a fix the owner seat tracks; details are withheld from this record. It changes which holder id the stand-down reads, not what the check refuses, so red lines 1 and 2 are unchanged.
  • Declarations. Metadata-protocol: none, so check-adr-0087-registration (fix(adr-0087): refuse not-required (no-migration-prescription) on a declared Metadata-protocol: narrowed #32649) owes no entry. Size is 2967 lines, which is under 3000.
  • Red lines 1, 2 and 3: hold at this head, with the measurements of 6819720516 carried for every unchanged line.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-bill · session_01Utp6CaP1iy4RSEQbW6177G

维护者速读

  1. 改了什么:授权权限集、开关权限集(激活入口)、克隆权限集三个入口都能用 permission_set_package / ?package= 指明是哪个包的那一个;同名权限集由两个包持有时,不指明包就答 409 并列出候选包。授权唯一键加上包这一列,开关台账按“包 + 名字”各占一行。
  2. 为什么改:ADR-0150 允许两个包各有一个同名权限集;这三个入口原来只按名字找,遇到同名要么答“不存在”,要么存错包。本 PR 是 plugin-security + runtime: the grant, clone and activation doors take a permission set's package and report a two-holder name with its candidates (ADR-0150 D3/D8; before #31698 PR-2) #32164 三段里的最后一段,落地后 objectql: narrow the phase-1 one-holder refusal from environment to namespace for permission set (ADR-0150 child B, permission set family) #31698 PR-2 才能放开同名。
  3. 风险与回滚:收窄(BREAKING)。只有一个包持有时三个入口的回答与 main 一致(17 项 md5 对照);收紧各处已在 changeset 写成 FROM → TO,第一方无调用会碰到。回滚即还原本 PR,无数据迁移。
  4. 先看:packages/plugins/plugin-security/src/stored-permission-set-package-stamp.ts,然后 packages/runtime/src/domains/catalog-activation.ts。
  5. 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/domains/permission-set-package-doors.test.ts,预期全过:两个包持有时不带包回 409 带两个候选;?package= 指 HR 只写 HR 的台账行。
  6. 需要你做的:无。2967 行,0/51 受管路径,契约复审与专题红线读都 PASS 后由席位落地。

席位意见:ACCEPT。契约复审 delta r4 PASS-with-nits(0 必修)在本 head;专题红线 delta 读 PASS 6820340332 在本 head。

席位核对 — ACCEPT · domain:server#2 · head 6075ae7189c8ac7ae5ac03e40694f4a1b2dd8d06 · 2026-11-12T10:23Z

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 12, 2026 10:24
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 12, 2026
Merged via the queue into main with commit 1e474e3 Nov 12, 2026
69 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-ekmc12 branch November 12, 2026 10:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant